/* * Copyright (C) 2011 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. */ package com.google.api.explorer.client.history; import com.google.api.explorer.client.Resources; import com.google.api.explorer.client.Resources.Css; import com.google.api.explorer.client.base.ApiMethod; import com.google.api.explorer.client.base.ApiMethod.HttpMethod; import com.google.api.explorer.client.base.ApiService; import com.google.api.explorer.client.base.Config; import com.google.api.explorer.client.base.Schema; import com.google.api.explorer.client.base.dynamicjso.DynamicJsArray; import com.google.api.explorer.client.base.dynamicjso.DynamicJso; import com.google.api.explorer.client.base.dynamicjso.JsType; import com.google.api.explorer.client.routing.HistoryWrapper; import com.google.api.explorer.client.routing.HistoryWrapperImpl; import com.google.api.explorer.client.routing.URLFragment; import com.google.api.explorer.client.routing.UrlBuilder; import com.google.api.explorer.client.routing.UrlBuilder.RootNavigationItem; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; import com.google.common.base.Predicate; import com.google.common.base.Strings; import com.google.common.collect.HashMultimap; import com.google.common.collect.ImmutableMultimap; import com.google.common.collect.Iterables; import com.google.common.collect.Lists; import com.google.common.collect.Multimap; import com.google.gwt.core.client.GWT; import com.google.gwt.core.client.JsonUtils; import com.google.gwt.event.dom.client.ClickEvent; import com.google.gwt.event.dom.client.ClickHandler; import com.google.gwt.event.dom.client.MouseOutEvent; import com.google.gwt.event.dom.client.MouseOutHandler; import com.google.gwt.json.client.JSONObject; import com.google.gwt.json.client.JSONString; import com.google.gwt.user.client.Window; import com.google.gwt.user.client.ui.Anchor; import com.google.gwt.user.client.ui.FlowPanel; import com.google.gwt.user.client.ui.FocusPanel; import com.google.gwt.user.client.ui.Image; import com.google.gwt.user.client.ui.InlineHyperlink; import com.google.gwt.user.client.ui.InlineLabel; import com.google.gwt.user.client.ui.Label; import com.google.gwt.user.client.ui.Panel; import com.google.gwt.user.client.ui.PopupPanel; import com.google.gwt.user.client.ui.PopupPanel.PositionCallback; import com.google.gwt.user.client.ui.PushButton; import com.google.gwt.user.client.ui.Widget; import java.util.Collection; import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.NoSuchElementException; import javax.annotation.Nullable; /** * A simple syntax highlighter for JSON data. * */ public class JsonPrettifier { /** * Class that we can use to re-write runtime Json exceptions to checked. */ public static class JsonFormatException extends Exception { private JsonFormatException(String message, Throwable cause) { super(message, cause); } } private static final String PLACEHOLDER_TEXT = "..."; private static final String SEPARATOR_TEXT = ","; private static final String OPEN_IN_NEW_WINDOW = "_blank"; private static final HistoryWrapper history = new HistoryWrapperImpl(); private static Css style; private static Resources resources; /** * Factory that can be used to manufacture link information that can vary between the full and * embedded explorer. */ public interface PrettifierLinkFactory { /** * Generate a click handler that will redirect to the fragment specified when invoked. */ ClickHandler generateMenuHandler(String fragment); /** * Generate an anchor widget which will redirect to the fragment specified when clicked. */ Widget generateAnchor(String embeddingText, String fragment); } /** * Link factory which generates links which manipulate the current browser view. This should be * used for the explorer full-view context. */ public static final PrettifierLinkFactory LOCAL_LINK_FACTORY = new PrettifierLinkFactory() { @Override public ClickHandler generateMenuHandler(final String fragment) { return new ClickHandler() { @Override public void onClick(ClickEvent event) { history.newItem(fragment); } }; } @Override public Widget generateAnchor(String embeddingText, String fragment) { return new InlineHyperlink(embeddingText, fragment); } }; /** * Link factory which generates links which either open a new tab, or switch the entire URL to a * new location. This should be used with the embedded explorer context. */ public static final PrettifierLinkFactory EXTERNAL_LINK_FACTORY = new PrettifierLinkFactory() { @Override public ClickHandler generateMenuHandler(final String fragment) { return new ClickHandler() { @Override public void onClick(ClickEvent event) { Window.open(createFullLink(fragment), "_blank", null); } }; } @Override public Widget generateAnchor(String embeddingText, String fragment) { return new Anchor(embeddingText, createFullLink(fragment)); } private String createFullLink(String fragment) { return Config.EXPLORER_URL + "#" + fragment; } }; private static class Collapser implements ClickHandler { private final Widget toHide; private final Widget placeHolder; private final Widget clicker; public Collapser(Widget toHide, Widget placeHolder, Widget clicker) { this.toHide = toHide; this.placeHolder = placeHolder; this.clicker = clicker; } @Override public void onClick(ClickEvent arg0) { boolean makeVisible = !toHide.isVisible(); decorateCollapserControl(clicker, makeVisible); toHide.setVisible(makeVisible); placeHolder.setVisible(!makeVisible); } public static void decorateCollapserControl(Widget collapser, boolean visible) { if (visible) { collapser.addStyleName(style.jsonExpanded()); collapser.removeStyleName(style.jsonCollapsed()); } else { collapser.addStyleName(style.jsonCollapsed()); collapser.removeStyleName(style.jsonExpanded()); } } } /** * This abstraction of an array creates formatted widgets from all children. */ private static class JsArrayIterable implements Iterable<Widget> { private final DynamicJsArray backingObj; private final int depth; private final ApiService service; private final PrettifierLinkFactory linkFactory; public JsArrayIterable( ApiService service, DynamicJsArray array, int depth, PrettifierLinkFactory linkFactory) { this.backingObj = array; this.depth = depth; this.service = service; this.linkFactory = linkFactory; } @Override public Iterator<Widget> iterator() { return new Iterator<Widget>() { private int nextOffset = 0; @Override public boolean hasNext() { return nextOffset < backingObj.length(); } @Override public Widget next() { if (!hasNext()) { throw new NoSuchElementException(); } Widget next = formatArrayValue(service, backingObj, nextOffset, depth, nextOffset + 1 < backingObj.length(), linkFactory); nextOffset++; return next; } @Override public void remove() { throw new UnsupportedOperationException(); } }; } } /** * This abstraction of an object creates formatted widgets from all children. */ private static class JsObjectIterable implements Iterable<Widget> { private final DynamicJso backingObj; private final int depth; private final ApiService service; private final PrettifierLinkFactory linkFactory; public JsObjectIterable( ApiService service, DynamicJso obj, int depth, PrettifierLinkFactory linkFactory) { this.backingObj = obj; this.depth = depth; this.service = service; this.linkFactory = linkFactory; } @Override public Iterator<Widget> iterator() { return new Iterator<Widget>() { int nextOffset = 0; @Override public boolean hasNext() { return nextOffset < backingObj.keys().length(); } @Override public Widget next() { if (!hasNext()) { throw new NoSuchElementException(); } Widget next = formatValue(service, backingObj, backingObj.keys().get(nextOffset), depth, nextOffset + 1 < backingObj.keys().length(), linkFactory); nextOffset++; return next; } @Override public void remove() { throw new UnsupportedOperationException(); } }; } } /** * Must be called before calling prettify to set the resources file to be used. Makes it possible * to test this class under JUnit. * * @param resources Resources (images and style) to use when prettifying. */ public static void setResources(Resources resources) { JsonPrettifier.resources = resources; JsonPrettifier.style = resources.style(); } /** * Entry point for the formatter. * * @param destination Destination GWT object where the results will be placed * @param jsonString String to format * @param linkFactory Which links factory should be used when generating links and navigation * menus. * @throws JsonFormatException when parsing the Json causes an error */ public static void prettify( ApiService service, Panel destination, String jsonString, PrettifierLinkFactory linkFactory) throws JsonFormatException { // Make sure the user set a style before invoking prettify. Preconditions.checkState(style != null, "Must call setStyle before using."); Preconditions.checkNotNull(service); Preconditions.checkNotNull(destination); // Don't bother syntax highlighting empty text. boolean empty = Strings.isNullOrEmpty(jsonString); destination.setVisible(!empty); if (empty) { return; } if (!GWT.isScript()) { // Syntax highlighting is *very* slow in Development Mode (~30s for large // responses), but very fast when compiled and run as JS (~30ms). For the // sake of my sanity, syntax highlighting is disabled in Development destination.add(new InlineLabel(jsonString)); } else { try { DynamicJso root = JsonUtils.<DynamicJso>safeEval(jsonString); Collection<ApiMethod> compatibleMethods = computeCompatibleMethods(root, service); Widget menuForMethods = createRequestMenu(compatibleMethods, service, root, linkFactory); JsObjectIterable rootObject = new JsObjectIterable(service, root, 1, linkFactory); Widget object = formatGroup(rootObject, "", 0, "{", "}", false, menuForMethods); destination.add(object); } catch (IllegalArgumentException e) { // JsonUtils will throw an IllegalArgumentException when it gets invalid // Json data. Rewrite as a checked exception and throw. throw new JsonFormatException("Invalid json.", e); } } } /** * Check the provided javascript object for a "kind" key and, and find all methods from the * provided service that accept the specified type for the request body. * * @param object Object which is checked against other methods. * @param service Service for which we want to find compatible methods. * @return Matching methods that accept the object type as an input, or an empty collection. */ private static Collection<ApiMethod> computeCompatibleMethods( DynamicJso object, ApiService service) { String kind = object.getString(Schema.KIND_KEY); if (kind != null) { return service.usagesOfKind(kind); } else { return Collections.emptyList(); } } /** * Iterate through an object or array adding the widgets generated for all children */ private static FlowPanel formatGroup(Iterable<Widget> objIterable, String title, int depth, String openGroup, String closeGroup, boolean hasSeparator, @Nullable Widget menuButtonForReuse) { FlowPanel object = new FlowPanel(); FlowPanel titlePanel = new FlowPanel(); Label paddingSpaces = new InlineLabel(indentation(depth)); titlePanel.add(paddingSpaces); Label titleLabel = new InlineLabel(title + openGroup); titleLabel.addStyleName(style.jsonKey()); Collapser.decorateCollapserControl(titleLabel, true); titlePanel.add(titleLabel); object.add(titlePanel); FlowPanel objectContents = new FlowPanel(); if (menuButtonForReuse != null) { objectContents.addStyleName(style.reusableResource()); objectContents.add(menuButtonForReuse); } for (Widget child : objIterable) { objectContents.add(child); } object.add(objectContents); InlineLabel placeholder = new InlineLabel(indentation(depth + 1) + PLACEHOLDER_TEXT); ClickHandler collapsingHandler = new Collapser(objectContents, placeholder, titleLabel); placeholder.setVisible(false); placeholder.addClickHandler(collapsingHandler); object.add(placeholder); titleLabel.addClickHandler(collapsingHandler); StringBuilder closingLabelText = new StringBuilder(indentation(depth)).append(closeGroup); if (hasSeparator) { closingLabelText.append(SEPARATOR_TEXT); } object.add(new Label(closingLabelText.toString())); return object; } private static Widget formatArrayValue(ApiService service, DynamicJsArray obj, int index, int depth, boolean hasSeparator, PrettifierLinkFactory linkFactory) { JsType type = obj.typeofIndex(index); if (type == null) { return simpleInline("", "null", style.jsonNull(), depth, hasSeparator); } String title = ""; switch (type) { case NUMBER: return simpleInline( title, String.valueOf(obj.getDouble(index)), style.jsonNumber(), depth, hasSeparator); case INTEGER: return simpleInline( title, String.valueOf(obj.getInteger(index)), style.jsonNumber(), depth, hasSeparator); case BOOLEAN: return simpleInline( title, String.valueOf(obj.getBoolean(index)), style.jsonBoolean(), depth, hasSeparator); case STRING: return inlineWidget( title, formatString(service, obj.getString(index), linkFactory), depth, hasSeparator); case ARRAY: return formatGroup( new JsArrayIterable(service, obj.<DynamicJsArray>get(index), depth + 1, linkFactory), title, depth, "[", "]", hasSeparator, null); case OBJECT: DynamicJso subObject = obj.<DynamicJso>get(index); // Determine if this object can be used as the request parameter for another method. Collection<ApiMethod> compatibleMethods = computeCompatibleMethods(subObject, service); Widget menuFromMethods = createRequestMenu(compatibleMethods, service, subObject, linkFactory); JsObjectIterable objIter = new JsObjectIterable(service, subObject, depth + 1, linkFactory); return formatGroup(objIter, title, depth, "{", "}", hasSeparator, menuFromMethods); } return new FlowPanel(); } private static Widget formatValue(ApiService service, DynamicJso obj, String key, int depth, boolean hasSeparator, PrettifierLinkFactory linkFactory) { JsType type = obj.typeofKey(key); if (type == null) { return simpleInline(titleString(key), "null", style.jsonNull(), depth, hasSeparator); } String title = titleString(key); switch (type) { case NUMBER: return simpleInline( title, String.valueOf(obj.getDouble(key)), style.jsonNumber(), depth, hasSeparator); case INTEGER: return simpleInline( title, String.valueOf(obj.getInteger(key)), style.jsonNumber(), depth, hasSeparator); case BOOLEAN: return simpleInline( title, String.valueOf(obj.getBoolean(key)), style.jsonBoolean(), depth, hasSeparator); case STRING: return inlineWidget( title, formatString(service, obj.getString(key), linkFactory), depth, hasSeparator); case ARRAY: return formatGroup( new JsArrayIterable(service, obj.<DynamicJsArray>get(key), depth + 1, linkFactory), title, depth, "[", "]", hasSeparator, null); case OBJECT: DynamicJso subObject = obj.<DynamicJso>get(key); // Determine if this object can be used as the request parameter for another method. Collection<ApiMethod> compatibleMethods = computeCompatibleMethods(subObject, service); JsObjectIterable objIter = new JsObjectIterable(service, subObject, depth + 1, linkFactory); return formatGroup(objIter, title, depth, "{", "}", hasSeparator, null); } return new FlowPanel(); } private static Widget simpleInline( String title, String inlineText, String style, int depth, boolean hasSeparator) { Widget valueLabel = new InlineLabel(inlineText); valueLabel.addStyleName(style); return inlineWidget(title, Lists.newArrayList(valueLabel), depth, hasSeparator); } private static Widget inlineWidget( String title, List<Widget> inlineWidgets, int depth, boolean hasSeparator) { FlowPanel inlinePanel = new FlowPanel(); StringBuilder keyText = new StringBuilder(indentation(depth)).append(title); InlineLabel keyLabel = new InlineLabel(keyText.toString()); keyLabel.addStyleName(style.jsonKey()); inlinePanel.add(keyLabel); for (Widget child : inlineWidgets) { inlinePanel.add(child); } if (hasSeparator) { inlinePanel.add(new InlineLabel(SEPARATOR_TEXT)); } return inlinePanel; } private static String indentation(int depth) { return Strings.repeat(" ", depth); } private static List<Widget> formatString( ApiService service, String rawText, PrettifierLinkFactory linkFactory) { if (isLink(rawText)) { List<Widget> response = Lists.newArrayList(); response.add(new InlineLabel("\"")); boolean createdExplorerLink = false; try { ApiMethod method = getMethodForUrl(service, rawText); if (method != null) { String explorerLink = createExplorerLink(service, rawText, method); Widget linkObject = linkFactory.generateAnchor(rawText, explorerLink); linkObject.addStyleName(style.jsonStringExplorerLink()); response.add(linkObject); createdExplorerLink = true; } } catch (IndexOutOfBoundsException e) { // Intentionally blank - this will only happen when iterating the method // url template in parallel with the url components and you run out of // components } if (!createdExplorerLink) { Anchor linkObject = new Anchor(rawText, rawText, OPEN_IN_NEW_WINDOW); linkObject.addStyleName(style.jsonStringLink()); response.add(linkObject); } response.add(new InlineLabel("\"")); return response; } else { JSONString encoded = new JSONString(rawText); Widget stringText = new InlineLabel(encoded.toString()); stringText.addStyleName(style.jsonString()); return Lists.newArrayList(stringText); } } private static String titleString(String name) { return "\"" + name + "\": "; } /** * Attempts to identify an {@link ApiMethod} corresponding to the given url. * If one is found, a {@link java.util.Map.Entry} will be returned where the key is the * name of the method, and the value is the {@link ApiMethod} itself. If no * method is found, this will return {@code null}. */ @VisibleForTesting static ApiMethod getMethodForUrl(ApiService service, String url) { String apiLinkPrefix = Config.getBaseUrl() + service.basePath(); if (!url.startsWith(apiLinkPrefix)) { return null; } // Only check GET methods since those are the only ones that can be returned // in the response. Iterable<ApiMethod> getMethods = Iterables.filter(service.allMethods().values(), new Predicate<ApiMethod>() { @Override public boolean apply(ApiMethod input) { return input.getHttpMethod() == HttpMethod.GET; } }); int paramIndex = url.indexOf("?"); String path = url.substring(0, paramIndex > 0 ? paramIndex : url.length()); for (ApiMethod method : getMethods) { // Try to match the request URL with its method by comparing it to the // method's rest base path URI template. To do this we have to remove the // {...} placeholders. String regex = apiLinkPrefix + method.getPath().replaceAll("\\{[^\\/]+\\}", "[^\\/]+"); if (path.matches(regex)) { return method; } } return null; } /** * Creates an Explorer link token (e.g., * #s/<service>/<version>/<method>) corresponding to the given request * URL, given the method name and method definition returned by * {@link #getMethodForUrl(ApiService, String)}. */ @VisibleForTesting static String createExplorerLink(ApiService service, String url, ApiMethod method) { UrlBuilder builder = new UrlBuilder(); // Add the basic information to the builder.addRootNavigationItem(RootNavigationItem.ALL_VERSIONS) .addService(service.getName(), service.getVersion()) .addMethodName(method.getId()); // Calculate the params from the path template and url. URLFragment parsed = URLFragment.parseFragment(url); Multimap<String, String> params = HashMultimap.create(); String pathTemplate = method.getPath(); if (pathTemplate.contains("{")) { String urlPath = parsed.getPath().replaceFirst(Config.getBaseUrl() + service.basePath(), ""); String[] templateSections = pathTemplate.split("/"); String[] urlSections = urlPath.split("/"); for (int i = 0; i < templateSections.length; i++) { if (templateSections[i].contains("{")) { String paramName = templateSections[i].substring(1, templateSections[i].length() - 1); params.put(paramName, urlSections[i]); } } } // Apply the params. String fullUrl = builder.addQueryParams(params).toString(); // Check if the url had query parameters to add. if (!parsed.getQueryString().isEmpty()) { fullUrl = fullUrl + parsed.getQueryString(); } return fullUrl; } private static boolean isLink(String value) { return (value.startsWith("http://") || value.startsWith("https://")) && !value.contains("\n") && !value.contains("\t"); } /** * Create a drop down menu that allows the user to navigate to compatible methods for the * specified resource. * * @param methods Methods for which to build the menu. * @param service Service to which the methods correspond. * @param objectToPackage Object which should be passed to the destination menus. * @param linkFactory Factory that will be used to create links. * @return A button that will show the menu that was generated or {@code null} if there are no * compatible methods. */ private static PushButton createRequestMenu(final Collection<ApiMethod> methods, final ApiService service, DynamicJso objectToPackage, PrettifierLinkFactory linkFactory) { // Determine if a menu even needs to be generated. if (methods.isEmpty()) { return null; } // Create the parameters that will be passed to the destination menu. String resourceContents = new JSONObject(objectToPackage).toString(); final Multimap<String, String> resourceParams = ImmutableMultimap.of(UrlBuilder.BODY_QUERY_PARAM_KEY, resourceContents); // Create the menu itself. FlowPanel menuContents = new FlowPanel(); // Add a description of what the menu does. Label header = new Label("Use this resource in one of the following methods:"); header.addStyleName(style.dropDownMenuItem()); menuContents.add(header); // Add a menu item for each method. for (ApiMethod method : methods) { PushButton methodItem = new PushButton(); methodItem.addStyleName(style.dropDownMenuItem()); methodItem.addStyleName(style.selectableDropDownMenuItem()); methodItem.setText(method.getId()); menuContents.add(methodItem); // When clicked, Navigate to the menu item. UrlBuilder builder = new UrlBuilder(); String newUrl = builder .addRootNavigationItem(RootNavigationItem.ALL_VERSIONS) .addService(service.getName(), service.getVersion()) .addMethodName(method.getId()) .addQueryParams(resourceParams) .toString(); methodItem.addClickHandler(linkFactory.generateMenuHandler(newUrl)); } // Create the panel which will be disclosed. final PopupPanel popupMenu = new PopupPanel(/* auto hide */ true); popupMenu.setStyleName(style.dropDownMenuPopup()); FocusPanel focusContents = new FocusPanel(); focusContents.addMouseOutHandler(new MouseOutHandler() { @Override public void onMouseOut(MouseOutEvent event) { popupMenu.hide(); } }); focusContents.setWidget(menuContents); popupMenu.setWidget(focusContents); // Create the button which will disclose the menu. final PushButton menuButton = new PushButton(new Image(resources.downArrow())); menuButton.addStyleName(style.reusableResourceButton()); menuButton.addClickHandler(new ClickHandler() { @Override public void onClick(ClickEvent event) { popupMenu.setPopupPositionAndShow(new PositionCallback() { @Override public void setPosition(int offsetWidth, int offsetHeight) { popupMenu.setPopupPosition( menuButton.getAbsoluteLeft() + menuButton.getOffsetWidth() - offsetWidth, menuButton.getAbsoluteTop() + menuButton.getOffsetHeight()); } }); } }); // Return only the button to the caller. return menuButton; } }